Lambda無しでGraphQLのAPIを作ろう!!AppSyncをAWSサービスのプロキシとして利用してみる
先日のブログでAppSyncのHTTPデータソースとしてStep Functionsを利用する構成をご紹介しました。AppSyncのデータソースとしてネイティブに対応していないサービスであっても、HTTPデータソースを介してAppSyncと連携させることが可能です。
この例のようにAppSyncをAWSサービスのプロキシとして構成することで、CRUD操作のような単純なオペレーションに関してはわざわざLamdbaを使わなくてもAPIを実装することが可能です。AWSとしてもre:inventのセッション等で「LambdaはTransformのために使い、Transportに使わない」ことがベストプラクティスだと発信しており、Lambda無しで実現できることは極力Lambdaを使わないように設定で吸収していきたいところです。ただ、API Gatewayをサービスプロキシとして構成するパターンは色々と情報が見つかるのですが、AppSyncに関してはまだまだ情報が少ない印象です。
ということで、このエントリではサーバーレスなシステム開発で利用頻度の高いAWSサービス&オペレーションの
- KinesisのPutRecord
- S3のPutObject
- SQSのSendMessage
- SNSのPublish
に関してAppSyncをサービスプロキシとして構成する方法をご紹介します。
CFnテンプレート
まずCFnのテンプレートをご紹介します。このテンプレートを使えば、簡単なサンプルが作成可能です。
AWSTemplateFormatVersion: '2010-09-09' Description: AppSync Service Proxy Sample Resources: ProxyApi: Type: AWS::AppSync::GraphQLApi Properties: AuthenticationType: API_KEY Name: AppSync Service Proxy API ProxyApiKey: Type: AWS::AppSync::ApiKey Properties: ApiId: !GetAtt ProxyApi.ApiId MessagesSchema: Type: AWS::AppSync::GraphQLSchema Properties: ApiId: !GetAtt ProxyApi.ApiId DefinitionS3Location: schema.graphql AppSyncServiceRole: Type: AWS::IAM::Role Properties: Path: / AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - sts:AssumeRole Principal: Service: - appsync.amazonaws.com Policies: - PolicyName: proxy-policy PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - s3:PutObject - sqs:SendMessage - kinesis:PutRecord - sns:Publish Resource: "*" S3Bucket: Type: AWS::S3::Bucket S3PutObjectDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: S3PutObjDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: s3 S3PutObjectMutationResolver: Type: AWS::AppSync::Resolver Properties: ApiId: !GetAtt ProxyApi.ApiId TypeName: Mutation FieldName: putObject DataSourceName: !GetAtt S3PutObjectDs.Name RequestMappingTemplate: | { "version": "2018-05-29", "method": "PUT", "resourcePath": "/test.json", "params": { "headers": { "Content-Type": "application/json" }, "body": $utils.toJson($ctx.arguments.data) } } ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $utils.toJson($ctx.result.headers) #else $utils.appendError($ctx.result.body, "$ctx.result.statusCode") #end SQSQueue: Type: AWS::SQS::Queue SQSSendMsgDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: SQSSendMessageDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://${AWS::Region}.queue.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: sqs SQSSendMsgMutationResolver: Type: AWS::AppSync::Resolver Properties: ApiId: !GetAtt ProxyApi.ApiId TypeName: Mutation FieldName: sendMessage DataSourceName: !GetAtt SQSSendMsgDs.Name RequestMappingTemplate: !Sub | #set($action = "Action=SendMessage") #set($version = "Version=2012-11-05") #set($queueurl = "QueueUrl=${SQSQueue}") #set($msgbody = "MessageBody=$ctx.arguments.data") { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "Content-Type": "application/x-www-form-urlencoded" }, "body": "$action&$version&$queueurl&$msgbody" } } ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $utils.xml.toJsonString($ctx.result.body) #else $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode") #end Kinesis: Type: AWS::Kinesis::Stream Properties: ShardCount: 1 KinesisPutRecDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: KinesisPutRecordDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://kinesis.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: kinesis KinesisPutRecMutationResolver: Type: AWS::AppSync::Resolver Properties: ApiId: !GetAtt ProxyApi.ApiId TypeName: Mutation FieldName: putRecord DataSourceName: !GetAtt KinesisPutRecDs.Name RequestMappingTemplate: !Sub | { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "X-Amz-Target": "Kinesis_20131202.PutRecord", "Content-Type": "application/x-amz-json-1.1" }, "body": { "StreamName": "${Kinesis}", "Data": "$util.base64Encode($ctx.arguments.data)", "PartitionKey": "$util.autoId()" } } } ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $util.toJson($ctx.result.body) #else $utils.appendError($util.toJson($ctx.result.body), "$ctx.result.statusCode") #end SNSTopic: Type: AWS::SNS::Topic SNSPublishDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: SNSPublishDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://sns.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: sns SNSPublishMutationResolver: Type: AWS::AppSync::Resolver Properties: ApiId: !GetAtt ProxyApi.ApiId TypeName: Mutation FieldName: publish DataSourceName: !GetAtt SNSPublishDs.Name RequestMappingTemplate: !Sub | #set($action = "Action=Publish") #set($version = "Version=2010-03-31") #set($topicarn = "TopicArn=${SNSTopic}") #set($msg = "Message=$ctx.arguments.data") { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "Content-Type": "application/x-www-form-urlencoded" }, "body": "$action&$version&$topicarn&$msg" } } ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $utils.xml.toJsonString($ctx.result.body) #else $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode") #end
GraphQLのスキーマです
input PutObj { attr1: String attr2: String } type Query { search(text: String): String } type Mutation { putObject(data: PutObj): String sendMessage(data: String): String putRecord(data: String): String publish(data: String): String } schema { query: Query mutation: Mutation }
S3のPutObjectについてはattr1,attr2というキーに持つオブジェクトを、その他のオペレーションは全てString型のデータを引数に受け取って、String型のデータを返却する仕様にしています。実案件で利用する際は適宜typeを定義するようにして下さい。
※searchというクエリはダミーで置いてるだけなので無視して下さい。
このテンプレートをpackage & deployすれば環境構築完了です。
$aws cloudformation package --template-file template.yml --s3-bucket <適当なS3バケット> --output-template-file output.yml $ aws cloudformation deploy --template-file output.yml --stack-name <適当なスタック名> --capabil ities CAPABILITY_AUTO_EXPAND CAPABILITY_IAM
ここからは各サービス&オペレーションに指定するテンプレートの詳細を解説していきます。
KinesisのPutRecord
まずKinesisのPutRecordです。HTTPデータソースは以下のように指定しています。
KinesisPutRecDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: KinesisPutRecordDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://kinesis.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: kinesis
こちらはあまり特記事項はありません。AuthorizationConfig
を指定し、AppSyncからエンドポイントにリクエストを発行する際にServiceRoleArn
で指定したIAMロールを使ってSIGv4の署名を付与するように設定しています。ServiceRoleArn
で指定するIAMロールはテンプレート内で作成しており、今回使用する4つのサービス&オペレーションに必要な権限を付与しています。
リクエストマッピングテンプレートは以下の通りです
RequestMappingTemplate: !Sub | { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "X-Amz-Target": "Kinesis_20131202.PutRecord", "Content-Type": "application/x-amz-json-1.1" }, "body": { "StreamName": "${Kinesis}", "Data": "$util.base64Encode($ctx.arguments.data)", "PartitionKey": "$util.autoId()" } } }
AppSyncからエンドポイントにリクエストを発行する際のHTTPヘッダーとしてX-Amz-Target
にKinesis_20131202.PutRecord
を指定することで、実行するオペレーション=PutRecordを指定します。
ホディにはPutRecordに必要なパラメータである
- StreamName
- Data
- PartitionKey
を指定しています。StreamNameは!Sub ${Kinesis}
と指定することで、テンプレート内で作成したKinesisのストリーム名を参照しています。Dataは$util.base64Encode($ctx.arguments.data)
を指定し、ミューテーションの引数に渡されたdataをbase64でエンコードした文字列を設定、PartitionKeyに関しては、$util.autoId
でUUIDを採番して利用しています。本番利用する際は、何かしらのコンテキスト情報から設定するのが良いでしょう。
やってみる
実際にAppSyncのコンソールからミューテーションを実行してみましょう
mutation putRecord{ putRecord(data: "hoge") }
OKです
S3のPutObject
続いてS3のPutObjectです。HTTPデータソースは以下のように指定しています。
S3PutObjectDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: S3PutObjDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: s3
S3のPutObjectはAPIはエンドポイントにバケット名を含めたURLを指定する必要があるためEndpoint: !Sub https://${S3Bucket}.s3.${AWS::Region}.amazonaws.com/
としてテンプレート内で作成したバケット名を参照させています。
リクエストマッピングテンプレートは以下の通りです
RequestMappingTemplate: | { "version": "2018-05-29", "method": "PUT", "resourcePath": "/test.json", "params": { "headers": { "Content-Type": "application/json" }, "body": $utils.toJson($ctx.arguments.data) } }
resourcePath
に/test.jsonという固定値を設定しています。S3のAPIはPUT時に指定されたパスがオブジェクトキーになるので、この設定であればtest.jsonというオブジェクトキーでJSONファイルがアップロードされることになります。実際に使用する際ははミューテーションに渡された入力値や$context.identity
からAPI実行ユーザーの情報(ユーザーID等)を取得してresourcePath
に反映すると良いでしょう。今回はPutObjectでJSONファイルをアップロードできるようにしたかったので、HTTPヘッダのContent-Type
は固定でapplication/json
とし、HTTPボディは$utils.toJson($ctx.arguments.data)
でミューテーションの引数に渡されたdata
をJSONに変換してPUTするようにしています。
レスポンスマッピングテンプレートです。
ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $utils.toJson($ctx.result.headers) #else $utils.appendError($ctx.result.body, "$ctx.result.statusCode") #end
正常終了のときにレスポンスボディが空で返ってくるので、代わりにレスポンスヘッダをJSONにして返却するようにしています。
やってみる
AppSyncのコンソールからミューテーションを実行してみます
mutation putObject{ putObject(data: { attr1: "hoge" attr2: "fuga" }) }
OKです
SQSのSendMessage
続いてSQSのSendMessageです。HTTPデータソースは以下のように指定しています。
SQSSendMsgDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: SQSSendMessageDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://${AWS::Region}.queue.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: sqs
こちらは特に特記事項はありません。リクエストマッピングテンプレートは以下の通りです
RequestMappingTemplate: !Sub | #set($action = "Action=SendMessage") #set($version = "Version=2012-11-05") #set($queueurl = "QueueUrl=${SQSQueue}") #set($msgbody = "MessageBody=$ctx.arguments.data") { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "Content-Type": "application/x-www-form-urlencoded" }, "body": "$action&$version&$queueurl&$msgbody" } }
SQSのAPIでPOSTリクエストを使用する場合はクエリパラメーターをボディに設定する必要があります。
リクエストマッピングテンプレート内で
#set($action = "Action=SendMessage") #set($version = "Version=2012-11-05") #set($queueurl = "QueueUrl=${SQSQueue}") #set($msgbody = "MessageBody=$ctx.arguments.data") ...略 "body": "$action&$version&$queueurl&$msgbody"
と指定することで、ミューテーションの引数data
に渡された文字列から最終的にリクエストボディに設定する
Action=SendMessage&Version=2012-11-05&QueueUrl=<キューのURL>&MessageBody=<メッセージ>
という文字列を生成しています。さらに、レスポンスのマッピングテンプレートを以下のように設定しています。
ResponseMappingTemplate: | #if($ctx.result.statusCode == 200) $utils.xml.toJsonString($ctx.result.body) #else $utils.appendError($utils.xml.toJsonString($ctx.result.body), "$ctx.result.statusCode") #end
SQSのAPIから返却されるレスポンスがXML形式になっているため、$utils.xml.toJsonString($ctx.result.body)
を使ってJSONに変換しています。
やってみる
AppSyncのコンソールからミューテーションを実行してみます
mutation sendMessage{ sendMessage(data: "hoge") }
OKです
SNSのPublish
最後にSNSのPublishです。HTTPデータソースは以下のように指定しています。
SNSPublishDs: Type: AWS::AppSync::DataSource Properties: ApiId: !GetAtt ProxyApi.ApiId Name: SNSPublishDataSource Type: HTTP ServiceRoleArn: !GetAtt AppSyncServiceRole.Arn HttpConfig: Endpoint: !Sub https://sns.${AWS::Region}.amazonaws.com/ AuthorizationConfig: AuthorizationType: AWS_IAM AwsIamConfig: SigningRegion: !Ref AWS::Region SigningServiceName: sns
こちらは特に特記事項はありません。
リクエストマッピングテンプレートは以下の通りです
#set($action = "Action=Publish") #set($version = "Version=2010-03-31") #set($topicarn = "TopicArn=${SNSTopic}") #set($msg = "Message=$ctx.arguments.data") { "version": "2018-05-29", "method": "POST", "resourcePath": "/", "params": { "headers": { "Content-Type": "application/x-www-form-urlencoded" }, "body": "$action&$version&$topicarn&$msg" } }
多少パラメータ名など違いますが、考え方はSQSのSendMessageと同じですね。SNSもSQSと同様レスポンスがXML形式になるため、レスポンスのマッピングテンプレートにSQSと同一の内容を設定しています。
やってみる
AppSyncのコンソールからミューテーションを実行してみます
mutation publish{ publish(data: "hoge") }
OKです
マッピングテンプレートを書くためのコツ
ここまで各サービスのマッピングテンプレートを見てきましたが、それぞれ微妙にAPIの仕様が異なることがお分かり頂けたかと思います。例えばKinesisの場合はリクエストヘッダのX-Amz-Target
を使ってアクションをSQSやSNSはリクエストボディで指定する といった具合です。では、別のサービスもしくは別のオペレーションに対してAppSyncをサービスプロキシとして構成したい場合はどのようにマッピングテンプレートを書いていけば良いのでしょうか?各サービスのAPI仕様書を漁っても良いのですが、個人的にオススメなのはAWS CLIの-debug
オプションです。AWS CLI実行時に--debug
オプションを指定することで、実際に発行されているリクエストの中身を確認することができます。AWS CLIからリクエストの構造の当たりを付けてPostmanから動作確認、動作が確認できたらテンプレートに落とし込んでいくという流れがオススメです。
例としてSQSのSendMessageについて考えてみましょう。まずはAWS CLIを--debug
付きで実行します。
$aws sqs send-message --message-body hoge --queue-url <SQSのエンドポイント> --debug
デバッグ出力が流れてくるので、Making request for...
と表示されている部分を探してHTTPボディとヘッダの中身を確認します。
※見やすいように少し加工しています
- MainThread - botocore.endpoint - DEBUG - Making request for OperationModel(name=SendMessage) with params: { 'url_path': '/', 'query_string': '', 'method': 'POST', 'headers': { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'User-Agent': 'aws-cli/1.18.36 Python/3.6.5 Darwin/18.7.0 botocore/1.15.36' }, 'body': { 'Action': 'SendMessage', 'Version': '2012-11-05', 'QueueUrl': 'https://sqs.ap-northeast-1.amazonaws.com/123456789012/AppSyncServiceProxy-SQSQueue-1169XBJXHNBYX', 'MessageBody': 'hoge' }, 'url': 'https://ap-northeast-1.queue.amazonaws.com/', 'context': {'client_region': 'ap-northeast-1', 'client_config': <botocore.config.Config object at 0x10ea617f0>, 'has_streaming_input': False, 'auth_type': None} }
リクエストの構造が確認できたらPostmanからリクエストを試してみます。Authorizationの設定から適宜必要事項を入力します。
続いてAWS CLIのデバッグ出力から確認した内容をもとにリクエストヘッダとリクエストボディを埋めていきます。一通り埋め終わったら、実際にAPIを実行してみてエンドポイントから正常応答が返却されるかを確認しましょう。
正常応答が返ってくればHTTPヘッダーとボディが正しく構成できているということです。ここまで確認できたらPostmanのCodeリンクをクリックします。
左側のペインでHTTPを選択することで生のHTTPリクエストが確認できるので、リクエストマッピングテンプレートの解決結果 = 生のHTTPリクエスト となるようにテンプレートを編集していきましょう。
まとめ
AppSyncをAWSサービスのプロキシとして構成する方法についてご紹介しました。簡単な権限チェックや固定値設定、加工程度であればLambdaを挟まなくてもリクエストマッピングテンプレートだけで対応することができるので、うまく活用して開発を高速化していきたいですね!